diff --git a/README.md b/README.md index a0955fb..3246eae 100644 --- a/README.md +++ b/README.md @@ -118,16 +118,20 @@ BRANCH=master bash scripts/deploy.sh ## Админка загрузки (по токену) -Новый контур на MySQL: +Новый контур на MySQL (основной): -- `admin-mysql.php?token=...` — админка -- `index-mysql.php` — публичная витрина + комментарии +- `admin.php?token=...` — админка +- `index.php` — публичная витрина + комментарии + +Совместимость: +- `admin-mysql.php` и `index-mysql.php` оставлены как алиасы на новые основные файлы. Что уже есть в MySQL-контуре: - создание разделов, -- загрузка фото "до" + опционально "после", +- сценарий загрузки: сначала выбор раздела, затем массовая загрузка только фото "до", +- после загрузки автоматический prefill имени (code_name) из имени файла, +- для каждой карточки фото можно отредактировать: имя, сортировку, комментарий и добавить/заменить фото "после", - запись в таблицы `sections`, `photos`, `photo_files`, -- просмотр разделов и загруженных фото, - персональные комментаторы (генерация ссылок), - плоские комментарии к фото, - удаление комментариев админом, diff --git a/admin-mysql.php b/admin-mysql.php index 6620e55..e63d760 100644 --- a/admin-mysql.php +++ b/admin-mysql.php @@ -1,257 +1,3 @@ getMessage()); -} - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $action = (string)($_POST['action'] ?? ''); - try { - if ($action === 'create_section') { - $name = trim((string)($_POST['name'] ?? '')); - $sort = (int)($_POST['sort_order'] ?? 1000); - if ($name === '') throw new RuntimeException('Название раздела пустое'); - sectionCreate($name, $sort); - $message = 'Раздел создан'; - } - - if ($action === 'upload_photo') { - $sectionId = (int)($_POST['section_id'] ?? 0); - $codeName = trim((string)($_POST['code_name'] ?? '')); - $sortOrder = (int)($_POST['sort_order'] ?? 1000); - $description = trim((string)($_POST['description'] ?? '')); - $description = $description !== '' ? $description : null; - - if ($sectionId < 1) throw new RuntimeException('Выбери раздел'); - if ($codeName === '') throw new RuntimeException('Укажи код фото (например АВФ1)'); - if (!isset($_FILES['before'])) throw new RuntimeException('Файл "до" обязателен'); - if (!sectionById($sectionId)) throw new RuntimeException('Раздел не найден'); - - $photoId = photoCreate($sectionId, $codeName, $description, $sortOrder); - - $before = saveImageUpload($_FILES['before'], $codeName, 'before', $sectionId); - photoFileUpsert($photoId, 'before', $before['path'], $before['mime'], $before['size']); - - if (isset($_FILES['after']) && (int)($_FILES['after']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { - $after = saveImageUpload($_FILES['after'], $codeName . 'р', 'after', $sectionId); - photoFileUpsert($photoId, 'after', $after['path'], $after['mime'], $after['size']); - } - - $message = 'Фото добавлено'; - } - - if ($action === 'create_commenter') { - $displayName = trim((string)($_POST['display_name'] ?? '')); - if ($displayName === '') throw new RuntimeException('Укажи имя комментатора'); - $u = commenterCreate($displayName); - $link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/index-mysql.php?viewer=' . urlencode($u['token']); - $message = 'Комментатор создан: ' . $u['display_name'] . ' | ссылка: ' . $link; - } - - if ($action === 'delete_commenter') { - $id = (int)($_POST['id'] ?? 0); - if ($id > 0) { - commenterDelete($id); - $message = 'Комментатор удалён (доступ отозван)'; - } - } - - if ($action === 'delete_comment') { - $id = (int)($_POST['id'] ?? 0); - if ($id > 0) { - commentDelete($id); - $message = 'Комментарий удалён'; - } - } - } catch (Throwable $e) { - $errors[] = $e->getMessage(); - } -} - -$sections = sectionsAll(); -$activeSectionId = (int)($_GET['section_id'] ?? ($sections[0]['id'] ?? 0)); -$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : []; -$commenters = commentersAll(); -$latestComments = commentsLatest(80); - -function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } -function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); } - -function saveImageUpload(array $file, string $baseName, string $kind, int $sectionId): array -{ - $allowedMime = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; - $err = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); - if ($err !== UPLOAD_ERR_OK) throw new RuntimeException("Ошибка загрузки ({$kind})"); - $size = (int)($file['size'] ?? 0); - if ($size < 1 || $size > MAX_UPLOAD_BYTES) throw new RuntimeException("Файл {$kind}: превышен лимит 3 МБ"); - - $tmp = (string)($file['tmp_name'] ?? ''); - if (!is_uploaded_file($tmp)) throw new RuntimeException("Файл {$kind}: некорректный источник"); - - $mime = mime_content_type($tmp) ?: ''; - if (!isset($allowedMime[$mime])) throw new RuntimeException("Файл {$kind}: недопустимый mime {$mime}"); - - $safeBase = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $baseName) ?? 'photo'; - $safeBase = trim($safeBase, '._-'); - if ($safeBase === '') $safeBase = 'photo'; - - $ext = $allowedMime[$mime]; - $dir = __DIR__ . '/photos/section_' . $sectionId; - if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) throw new RuntimeException('Не удалось создать папку раздела'); - - $final = uniqueName($dir, $safeBase, $ext); - $dest = $dir . '/' . $final; - if (!move_uploaded_file($tmp, $dest)) throw new RuntimeException('Не удалось сохранить файл'); - - return [ - 'path' => 'photos/section_' . $sectionId . '/' . $final, - 'mime' => $mime, - 'size' => $size, - ]; -} - -function uniqueName(string $dir, string $base, string $ext): string -{ - $i = 0; - do { - $name = $i === 0 ? "{$base}.{$ext}" : "{$base}_{$i}.{$ext}"; - $i++; - } while (is_file($dir . '/' . $name)); - return $name; -} -?> - - - - - Админка (MySQL) - - - - -
-

Админка (MySQL)

-

Открыть публичную MySQL-галерею

-
-
- -
-
-

Создать раздел

-
- -

-

- -
-
- -
-

Добавить фото

-
- -

-

-

-

-

Фото до:

-

Фото после (опционально):

- -
-
- -
-

Комментаторы (персональные ссылки)

-
- - - -
- - - - - - - - - -
IDИмяСтатусДействие
-
- - -
-
-

После создания ссылки токен показывается один раз в зелёном сообщении.

-
- -
-

Разделы

- - - - -
IDНазваниеПорядокФото
-
- -
-

Фото раздела

- - - - - - - - - - -
IDКодПревьюОписаниеПорядок
-
- -
-

Последние комментарии

- - - - - - - - - - -
IDФотоПользовательКомментарий
-
- - -
-
-
-
-
+// Backward-compat alias +require __DIR__ . '/admin.php'; diff --git a/admin.php b/admin.php index eac33ec..e3e681d 100644 --- a/admin.php +++ b/admin.php @@ -2,330 +2,348 @@ declare(strict_types=1); +require_once __DIR__ . '/lib/db_gallery.php'; + const MAX_UPLOAD_BYTES = 3 * 1024 * 1024; -$rootDir = __DIR__; -$photosDir = $rootDir . '/photos'; -$thumbsDir = $rootDir . '/thumbs'; -$dataDir = $rootDir . '/data'; -$sortFile = $dataDir . '/sort.json'; - -@mkdir($photosDir, 0775, true); -@mkdir($thumbsDir, 0775, true); -@mkdir($dataDir, 0775, true); - $configPath = __DIR__ . '/deploy-config.php'; if (!is_file($configPath)) { http_response_code(500); - echo 'deploy-config.php not found'; - exit; + exit('deploy-config.php not found'); } $config = require $configPath; $tokenExpected = (string)($config['token'] ?? ''); $tokenIncoming = (string)($_REQUEST['token'] ?? ''); - if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) { http_response_code(403); - echo 'Forbidden'; - exit; + exit('Forbidden'); } -$sortData = loadSortData($sortFile); -$sortData = reconcileSortData($photosDir, $sortData); -saveSortData($sortFile, $sortData); $message = ''; $errors = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = (string)($_POST['action'] ?? ''); - if ($action === 'create_category') { - $name = sanitizeCategoryName((string)($_POST['category_name'] ?? '')); - if ($name === '') { - $errors[] = 'Некорректное имя папки.'; - } else { - $dir = $photosDir . '/' . $name; - if (!is_dir($dir) && !mkdir($dir, 0775, true)) { - $errors[] = 'Не удалось создать папку.'; - } else { - $message = 'Папка создана: ' . $name; - $sortData['categories'][$name] = nextSortIndex($sortData['categories']); - } + try { + if ($action === 'create_section') { + $name = trim((string)($_POST['name'] ?? '')); + $sort = (int)($_POST['sort_order'] ?? 1000); + if ($name === '') throw new RuntimeException('Название раздела пустое'); + sectionCreate($name, $sort); + $message = 'Раздел создан'; } - } - if ($action === 'category_update') { - $current = sanitizeCategoryName((string)($_POST['category_current'] ?? '')); - $newName = sanitizeCategoryName((string)($_POST['category_new_name'] ?? '')); - $sortIndex = (int)($_POST['category_sort'] ?? 1000); + if ($action === 'upload_before_bulk') { + $sectionId = (int)($_POST['section_id'] ?? 0); + if ($sectionId < 1 || !sectionById($sectionId)) throw new RuntimeException('Выбери раздел'); + if (!isset($_FILES['before_bulk'])) throw new RuntimeException('Файлы не переданы'); - if ($current === '' || !is_dir($photosDir . '/' . $current)) { - $errors[] = 'Категория не найдена.'; - } else { - if ($newName !== '' && $newName !== $current) { - $oldDir = $photosDir . '/' . $current; - $newDir = $photosDir . '/' . $newName; - $oldThumb = $thumbsDir . '/' . $current; - $newThumb = $thumbsDir . '/' . $newName; - - if (is_dir($newDir)) { - $errors[] = 'Категория с таким именем уже существует.'; - } else { - rename($oldDir, $newDir); - if (is_dir($oldThumb)) { - @rename($oldThumb, $newThumb); - } - - if (isset($sortData['categories'][$current])) { - $sortData['categories'][$newName] = $sortData['categories'][$current]; - unset($sortData['categories'][$current]); - } - if (isset($sortData['photos'][$current])) { - $sortData['photos'][$newName] = $sortData['photos'][$current]; - unset($sortData['photos'][$current]); - } - $current = $newName; - $message = 'Категория переименована.'; - } - } - - $sortData['categories'][$current] = $sortIndex; - $message = $message ?: 'Категория обновлена.'; - } - } - - if ($action === 'category_delete') { - $category = sanitizeCategoryName((string)($_POST['category_current'] ?? '')); - if ($category === '' || !is_dir($photosDir . '/' . $category)) { - $errors[] = 'Категория не найдена.'; - } else { - rrmdir($photosDir . '/' . $category); - rrmdir($thumbsDir . '/' . $category); - unset($sortData['categories'][$category], $sortData['photos'][$category]); - $message = 'Категория удалена: ' . $category; - } - } - - if ($action === 'upload') { - $category = sanitizeCategoryName((string)($_POST['category'] ?? '')); - if ($category === '' || !is_dir($photosDir . '/' . $category)) { - $errors[] = 'Выберите существующую категорию.'; - } elseif (!isset($_FILES['photos'])) { - $errors[] = 'Файлы не переданы.'; - } else { - $result = handleUploads($_FILES['photos'], $photosDir . '/' . $category, $sortData, $category); + $result = saveBulkBefore($_FILES['before_bulk'], $sectionId); + $message = 'Загружено: ' . $result['ok']; $errors = array_merge($errors, $result['errors']); - if ($result['ok'] > 0) { - $message = 'Загружено: ' . $result['ok']; - } } - } - if ($action === 'photo_update') { - $category = sanitizeCategoryName((string)($_POST['category'] ?? '')); - $currentFile = basename((string)($_POST['photo_current'] ?? '')); - $newBase = sanitizeFileBase((string)($_POST['photo_new_name'] ?? '')); - $sortIndex = (int)($_POST['photo_sort'] ?? 1000); + if ($action === 'photo_update') { + $photoId = (int)($_POST['photo_id'] ?? 0); + $code = trim((string)($_POST['code_name'] ?? '')); + $sort = (int)($_POST['sort_order'] ?? 1000); + $descr = trim((string)($_POST['description'] ?? '')); + $descr = $descr !== '' ? $descr : null; - $src = $photosDir . '/' . $category . '/' . $currentFile; - if ($category === '' || $currentFile === '' || !is_file($src)) { - $errors[] = 'Фото не найдено.'; - } else { - $finalName = $currentFile; - if ($newBase !== '') { - $ext = strtolower(pathinfo($currentFile, PATHINFO_EXTENSION)); - $candidate = uniqueFileNameForRename($photosDir . '/' . $category, $newBase, $ext, $currentFile); - if ($candidate !== $currentFile) { - $dst = $photosDir . '/' . $category . '/' . $candidate; - if (@rename($src, $dst)) { - $oldThumb = $thumbsDir . '/' . $category . '/' . pathinfo($currentFile, PATHINFO_FILENAME) . '.jpg'; - $newThumb = $thumbsDir . '/' . $category . '/' . pathinfo($candidate, PATHINFO_FILENAME) . '.jpg'; - if (is_file($oldThumb)) { - @rename($oldThumb, $newThumb); + if ($photoId < 1) throw new RuntimeException('Некорректный photo_id'); + if ($code === '') throw new RuntimeException('Код фото пустой'); + + $st = db()->prepare('UPDATE photos SET code_name=:c, sort_order=:s, description=:d WHERE id=:id'); + $st->execute(['c' => $code, 's' => $sort, 'd' => $descr, 'id' => $photoId]); + + if (isset($_FILES['after']) && (int)($_FILES['after']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $p = photoById($photoId); + if (!$p) throw new RuntimeException('Фото не найдено'); + $up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']); + photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']); + } + + $message = 'Фото обновлено'; + } + + if ($action === 'photo_delete') { + $photoId = (int)($_POST['photo_id'] ?? 0); + if ($photoId > 0) { + $p = photoById($photoId); + if ($p) { + foreach (['before_path', 'after_path'] as $k) { + if (!empty($p[$k])) { + $abs = __DIR__ . '/' . ltrim((string)$p[$k], '/'); + if (is_file($abs)) @unlink($abs); } - if (isset($sortData['photos'][$category][$currentFile])) { - $sortData['photos'][$category][$candidate] = $sortData['photos'][$category][$currentFile]; - unset($sortData['photos'][$category][$currentFile]); - } - $finalName = $candidate; } } + $st = db()->prepare('DELETE FROM photos WHERE id=:id'); + $st->execute(['id' => $photoId]); + $message = 'Фото удалено'; } - - $sortData['photos'][$category][$finalName] = $sortIndex; - $message = 'Фото обновлено.'; } - } - if ($action === 'photo_delete') { - $category = sanitizeCategoryName((string)($_POST['category'] ?? '')); - $file = basename((string)($_POST['photo_current'] ?? '')); - $src = $photosDir . '/' . $category . '/' . $file; - if ($category === '' || $file === '' || !is_file($src)) { - $errors[] = 'Фото не найдено.'; - } else { - @unlink($src); - $thumb = $thumbsDir . '/' . $category . '/' . pathinfo($file, PATHINFO_FILENAME) . '.jpg'; - if (is_file($thumb)) { - @unlink($thumb); + if ($action === 'create_commenter') { + $displayName = trim((string)($_POST['display_name'] ?? '')); + if ($displayName === '') throw new RuntimeException('Укажи имя комментатора'); + $u = commenterCreate($displayName); + $link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode($u['token']); + $message = 'Комментатор создан: ' . $u['display_name'] . ' | ссылка: ' . $link; + } + + if ($action === 'delete_commenter') { + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + commenterDelete($id); + $message = 'Комментатор удалён (доступ отозван)'; } - unset($sortData['photos'][$category][$file]); - $message = 'Фото удалено.'; + } + + if ($action === 'delete_comment') { + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + commentDelete($id); + $message = 'Комментарий удалён'; + } + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } +} + +$sections = sectionsAll(); +$activeSectionId = (int)($_GET['section_id'] ?? ($_POST['section_id'] ?? ($sections[0]['id'] ?? 0))); +$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : []; +$commenters = commentersAll(); +$latestComments = commentsLatest(80); + +function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } +function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); } + +function saveBulkBefore(array $files, int $sectionId): array +{ + $ok = 0; + $errors = []; + + $names = $files['name'] ?? []; + $tmp = $files['tmp_name'] ?? []; + $sizes = $files['size'] ?? []; + $errs = $files['error'] ?? []; + if (!is_array($names)) { + $names = [$names]; + $tmp = [$tmp]; + $sizes = [$sizes]; + $errs = [$errs]; + } + + foreach ($names as $i => $orig) { + $file = [ + 'name' => $orig, + 'tmp_name' => $tmp[$i] ?? '', + 'size' => $sizes[$i] ?? 0, + 'error' => $errs[$i] ?? UPLOAD_ERR_NO_FILE, + ]; + + try { + $base = (string)pathinfo((string)$orig, PATHINFO_FILENAME); + $base = trim(preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $base) ?? 'photo', '._-'); + if ($base === '') $base = 'photo'; + + $codeName = nextUniqueCodeName($base); + $photoId = photoCreate($sectionId, $codeName, null, nextSortOrderForSection($sectionId)); + $saved = saveSingleImage($file, $codeName, $sectionId); + photoFileUpsert($photoId, 'before', $saved['path'], $saved['mime'], $saved['size']); + $ok++; + } catch (Throwable $e) { + $errors[] = (string)$orig . ': ' . $e->getMessage(); } } - saveSortData($sortFile, $sortData); + return ['ok' => $ok, 'errors' => $errors]; } -$categories = listCategories($photosDir, $sortData); -$selectedCategory = sanitizeCategoryName((string)($_GET['edit_category'] ?? ($_POST['category'] ?? ''))); -$photos = $selectedCategory !== '' ? listPhotos($photosDir, $thumbsDir, $selectedCategory, $sortData) : []; +function saveSingleImage(array $file, string $baseName, int $sectionId): array +{ + $allowedMime = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; + $err = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); + if ($err !== UPLOAD_ERR_OK) throw new RuntimeException('Ошибка загрузки'); + $size = (int)($file['size'] ?? 0); + if ($size < 1 || $size > MAX_UPLOAD_BYTES) throw new RuntimeException('Превышен лимит 3 МБ'); -?> - - -Админка галереи - - - -
-

Админка галереи

-

← В галерею

-
-
+ $tmp = (string)($file['tmp_name'] ?? ''); + if (!is_uploaded_file($tmp)) throw new RuntimeException('Некорректный источник'); -
-
-

Создать папку

-
- - -

-
-
+ $mime = mime_content_type($tmp) ?: ''; + if (!isset($allowedMime[$mime])) throw new RuntimeException('Недопустимый тип файла'); -
-

Категории (редактирование / сортировка / удаление)

- - - - - - - - - -
КатегорияПорядокНовое имяДействия
-
- - - -
- - -
- - -
-
-
+ $safeBase = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $baseName) ?? 'photo'; + $safeBase = trim($safeBase, '._-'); + if ($safeBase === '') $safeBase = 'photo'; -
-

Фото в категории:

- -
- - - - -
-

Только JPG/PNG/WEBP/GIF, максимум 3 МБ на файл.

- - -

Сначала выбери категорию в блоке выше (клик по её названию).

- -

В категории пока нет фото.

- - - - - - - - - - - -
ПревьюФотоПорядокНовое имя (без расширения)Действия
-
- - - -
- - -
- - - -
-
- -
-
-[],'photos'=>[]]; $d=json_decode((string)file_get_contents($file),true); return is_array($d)?['categories'=>(array)($d['categories']??[]),'photos'=>(array)($d['photos']??[])]:['categories'=>[],'photos'=>[]]; } -function saveSortData(string $file, array $data): void { file_put_contents($file, json_encode($data, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)); } -function nextSortIndex(array $map): int { return $map===[]?10:((int)max(array_map('intval',$map))+10); } -function listCategories(string $photosDir, array $sortData): array { $out=[]; foreach((@scandir($photosDir)?:[]) as $x){ if($x==='.'||$x==='..')continue; if(is_dir($photosDir.'/'.$x))$out[]=$x; } usort($out, fn($a,$b)=>((int)($sortData['categories'][$a]??1000)<=> (int)($sortData['categories'][$b]??1000)) ?: strnatcasecmp($a,$b)); return $out; } -function listPhotos(string $photosDir, string $thumbsDir, string $category, array $sortData): array { $out=[]; $dir=$photosDir.'/'.$category; foreach((@scandir($dir)?:[]) as $f){ if($f==='.'||$f==='..')continue; $p=$dir.'/'.$f; if(!is_file($p)||!isImageExt($f)) continue; $thumbAbs=$thumbsDir.'/'.$category.'/'.pathinfo($f, PATHINFO_FILENAME).'.jpg'; $thumb=is_file($thumbAbs)?('thumbs/'.rawurlencode($category).'/'.rawurlencode(pathinfo($f, PATHINFO_FILENAME).'.jpg')):''; $out[]=['file'=>$f,'sort'=>(int)($sortData['photos'][$category][$f]??1000),'thumb'=>$thumb]; } usort($out, fn($a,$b)=>($a['sort']<=>$b['sort']) ?: strnatcasecmp($a['file'],$b['file'])); return $out; } + $ext = $allowedMime[$mime]; + $dir = __DIR__ . '/photos/section_' . $sectionId; + if (!is_dir($dir)) mkdir($dir, 0775, true); + $name = uniqueName($dir, $safeBase, $ext); + $dest = $dir . '/' . $name; -function reconcileSortData(string $photosDir, array $sortData): array { - $clean=['categories'=>[],'photos'=>[]]; - $cats=[]; - foreach((@scandir($photosDir)?:[]) as $c){ - if($c==='.'||$c==='..') continue; - if(!is_dir($photosDir.'/'.$c)) continue; - $cats[]=$c; - } - foreach($cats as $c){ - $clean['categories'][$c]=(int)($sortData['categories'][$c] ?? 1000); - $clean['photos'][$c]=[]; - foreach((@scandir($photosDir.'/'.$c)?:[]) as $f){ - if($f==='.'||$f==='..') continue; - if(!is_file($photosDir.'/'.$c.'/'.$f) || !isImageExt($f)) continue; - $clean['photos'][$c][$f]=(int)($sortData['photos'][$c][$f] ?? 1000); + if (!move_uploaded_file($tmp, $dest)) throw new RuntimeException('Не удалось сохранить файл'); + + return [ + 'path' => 'photos/section_' . $sectionId . '/' . $name, + 'mime' => $mime, + 'size' => $size, + ]; +} + +function uniqueName(string $dir, string $base, string $ext): string +{ + $i = 0; + do { + $name = $i === 0 ? "{$base}.{$ext}" : "{$base}_{$i}.{$ext}"; + $i++; + } while (is_file($dir . '/' . $name)); + return $name; +} + +function nextSortOrderForSection(int $sectionId): int +{ + $st = db()->prepare('SELECT COALESCE(MAX(sort_order),0)+10 FROM photos WHERE section_id=:sid'); + $st->execute(['sid' => $sectionId]); + return (int)$st->fetchColumn(); +} + +function nextUniqueCodeName(string $base): string +{ + $candidate = $base; + $i = 1; + while (true) { + $st = db()->prepare('SELECT 1 FROM photos WHERE code_name=:c LIMIT 1'); + $st->execute(['c' => $candidate]); + if (!$st->fetchColumn()) { + return $candidate; } + $candidate = $base . '_' . $i; + $i++; } - return $clean; -} -function isImageExt(string $file): bool { return in_array(strtolower(pathinfo($file, PATHINFO_EXTENSION)), ['jpg','jpeg','png','webp','gif'], true); } -function rrmdir(string $dir): void { if(!is_dir($dir)) return; $it=scandir($dir)?:[]; foreach($it as $x){ if($x==='.'||$x==='..')continue; $p=$dir.'/'.$x; if(is_dir($p)) rrmdir($p); else @unlink($p);} @rmdir($dir); } -function uniqueFileNameForRename(string $dir,string $base,string $ext,string $current): string{ $n=0; do{ $cand=$n===0?"{$base}.{$ext}":"{$base}_{$n}.{$ext}"; if($cand===$current||!file_exists($dir.'/'.$cand)) return $cand; $n++; }while(true); } -function handleUploads(array $files, string $targetDir, array &$sortData, string $category): array { - $allowedMime=['image/jpeg','image/png','image/webp','image/gif']; $allowedExt=['jpg','jpeg','png','webp','gif']; $ok=0; $errors=[]; - $names=$files['name']??[]; $tmp=$files['tmp_name']??[]; $sizes=$files['size']??[]; $errs=$files['error']??[]; - if(!is_array($names)){ $names=[$names]; $tmp=[$tmp]; $sizes=[$sizes]; $errs=[$errs]; } - $finfo=finfo_open(FILEINFO_MIME_TYPE); - foreach($names as $i=>$orig){ if((int)($errs[$i]??UPLOAD_ERR_NO_FILE)!==UPLOAD_ERR_OK){$errors[]="{$orig}: ошибка загрузки";continue;} - $size=(int)($sizes[$i]??0); if($size<1||$size>MAX_UPLOAD_BYTES){$errors[]="{$orig}: >3MB";continue;} - $tmpFile=(string)($tmp[$i]??''); if($tmpFile===''||!is_uploaded_file($tmpFile)){ $errors[]="{$orig}: источник"; continue;} - $mime=$finfo?(string)finfo_file($finfo,$tmpFile):''; if(!in_array($mime,$allowedMime,true)){ $errors[]="{$orig}: тип {$mime}"; continue;} - $ext=strtolower(pathinfo((string)$orig, PATHINFO_EXTENSION)); if(!in_array($ext,$allowedExt,true)){ $errors[]="{$orig}: расширение"; continue;} - $base=sanitizeFileBase(pathinfo((string)$orig, PATHINFO_FILENAME)); if($base==='')$base='photo'; $name=uniqueFileNameForRename($targetDir,$base,$ext,''); - if(!move_uploaded_file($tmpFile,$targetDir.'/'.$name)){ $errors[]="{$orig}: не сохранить"; continue; } - $sortData['photos'][$category][$name]=nextSortIndex((array)($sortData['photos'][$category]??[])); $ok++; - } - if($finfo)finfo_close($finfo); - return ['ok'=>$ok,'errors'=>$errors]; } +?> + + + + + Админка + + + + +
+

Админка

+
+
+ +
+ + +
+
+

Загрузка фото “до” в выбранный раздел

+ 0): ?> +
+ +

+ +
+

После загрузки имя (code_name) заполняется автоматически из имени файла — затем можно отредактировать.

+ +

Сначала выбери раздел слева.

+ +
+ +
+

Фото в разделе

+ + + + + + + + + +
ПревьюПоляДействия
+
+ +

+

+

+

Фото после (опционально):

+ +
+
+
+ + +
+
+
+ +
+

Комментаторы и комментарии

+ + + + +
ПользовательДействие
+
+ + +
+
+
+ + + + + + + + + +
ФотоПользовательКомментарий
+
+ + +
+
+
+
+
+
diff --git a/index-mysql.php b/index-mysql.php index 006b9f5..871b49b 100644 --- a/index-mysql.php +++ b/index-mysql.php @@ -1,211 +1,3 @@ 0 && $text !== '') { - $u = commenterByToken($token); - if ($u) { - commentAdd($photoId, (int)$u['id'], mb_substr($text, 0, 1000)); - } - } - - $redirect = './index-mysql.php?photo_id=' . $photoId; - if ($token !== '') { - $redirect .= '&viewer=' . urlencode($token); - } - header('Location: ' . $redirect); - exit; -} - -$sections = sectionsAll(); -$activeSectionId = (int)($_GET['section_id'] ?? 0); -$activePhotoId = (int)($_GET['photo_id'] ?? 0); - -if ($activePhotoId > 0) { - $photo = photoById($activePhotoId); - if (!$photo) { - http_response_code(404); - $photo = null; - } - $comments = $photo ? commentsByPhoto($activePhotoId) : []; -} else { - $photo = null; - $comments = []; -} - -$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : []; - -function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } -function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); } - -function serveImage(): never -{ - $fileId = (int)($_GET['file_id'] ?? 0); - if ($fileId < 1) { - http_response_code(404); - exit; - } - - $f = photoFileById($fileId); - if (!$f) { - http_response_code(404); - exit; - } - - $abs = __DIR__ . '/' . ltrim((string)$f['file_path'], '/'); - if (!is_file($abs)) { - http_response_code(404); - exit; - } - - $kind = (string)$f['kind']; - if ($kind !== 'after') { - header('Content-Type: ' . ((string)$f['mime_type'] ?: 'application/octet-stream')); - header('Content-Length: ' . (string)filesize($abs)); - header('Cache-Control: private, max-age=60'); - header('X-Robots-Tag: noindex, nofollow'); - readfile($abs); - exit; - } - - outputWatermarked($abs, (string)$f['mime_type']); -} - -function outputWatermarked(string $path, string $mime): never -{ - $text = 'photo.andr33v.ru'; - - if (extension_loaded('imagick')) { - $im = new Imagick($path); - $draw = new ImagickDraw(); - $draw->setFillColor(new ImagickPixel('rgba(255,255,255,0.22)')); - $draw->setFontSize(max(18, (int)($im->getImageWidth() / 24))); - $draw->setGravity(Imagick::GRAVITY_SOUTHEAST); - $im->annotateImage($draw, 20, 24, -15, $text); - header('Content-Type: ' . ($mime !== '' ? $mime : 'image/jpeg')); - $im->setImageCompressionQuality(88); - echo $im; - $im->clear(); - $im->destroy(); - exit; - } - - [$w, $h, $type] = @getimagesize($path) ?: [0,0,0]; - if ($w < 1 || $h < 1) { - readfile($path); - exit; - } - - $img = match ($type) { - IMAGETYPE_JPEG => imagecreatefromjpeg($path), - IMAGETYPE_PNG => imagecreatefrompng($path), - IMAGETYPE_GIF => imagecreatefromgif($path), - IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($path) : null, - default => null, - }; - - if (!$img) { - readfile($path); - exit; - } - - $font = 5; - $color = imagecolorallocatealpha($img, 255, 255, 255, 90); - $x = max(5, $w - (imagefontwidth($font) * strlen($text)) - 15); - $y = max(5, $h - imagefontheight($font) - 12); - imagestring($img, $font, $x, $y, $text, $color); - - header('Content-Type: image/jpeg'); - imagejpeg($img, null, 88); - imagedestroy($img); - exit; -} -?> - - - - - Фотогалерея (MySQL) - - - - - -
-

Фотогалерея

Простая галерея, которая управляется через файловый менеджер.

-
- -
- 0 && $photo): ?> -
-

← к разделу

-

-

-
-
До обработки
-
После обработки (watermark)
-
- -

Комментарии

- -
- - - - -

-
- -

Комментарии может оставлять только пользователь с персональной ссылкой.

- - - -
·
- -
- -
-

Фотографии

- -

Выберите раздел слева.

- -

В разделе пока нет фотографий.

- -
- - - -

-
- -
- -
- -
-
-
- - +// Backward-compat alias +require __DIR__ . '/index.php'; diff --git a/index.php b/index.php index 44c2d9a..ccb80a2 100644 --- a/index.php +++ b/index.php @@ -2,376 +2,192 @@ declare(strict_types=1); -const THUMB_WIDTH = 360; -const THUMB_HEIGHT = 240; +require_once __DIR__ . '/lib/db_gallery.php'; -$baseDir = __DIR__; -$photosDir = $baseDir . '/photos'; -$thumbsDir = $baseDir . '/thumbs'; -$dataDir = $baseDir . '/data'; -$lastIndexedFile = $dataDir . '/last_indexed.txt'; -$sortFile = $dataDir . '/sort.json'; - -ensureDirectories([$photosDir, $thumbsDir, $dataDir]); -$sortData = loadSortData($sortFile); - -$action = $_GET['action'] ?? null; +$action = (string)($_GET['action'] ?? ''); if ($action === 'image') { - serveImage($photosDir); + serveImage(); } -$lastIndexedTimestamp = readLastIndexedTimestamp($lastIndexedFile); -$maxTimestamp = $lastIndexedTimestamp; +$viewerToken = trim((string)($_GET['viewer'] ?? '')); +$viewer = $viewerToken !== '' ? commenterByToken($viewerToken) : null; -$categories = scanCategories($photosDir, $sortData); +if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'add_comment') { + $token = trim((string)($_POST['viewer'] ?? '')); + $photoId = (int)($_POST['photo_id'] ?? 0); + $text = trim((string)($_POST['comment_text'] ?? '')); -foreach ($categories as $categoryName => &$images) { - $categoryThumbDir = $thumbsDir . '/' . $categoryName; - if (!is_dir($categoryThumbDir)) { - mkdir($categoryThumbDir, 0775, true); - } - - foreach ($images as &$image) { - $sourcePath = $image['abs_path']; - $sourceMtime = (int) filemtime($sourcePath); - $maxTimestamp = max($maxTimestamp, $sourceMtime); - - $thumbExt = 'jpg'; - $thumbName = pathinfo($image['filename'], PATHINFO_FILENAME) . '.jpg'; - $thumbAbsPath = $categoryThumbDir . '/' . $thumbName; - $thumbWebPath = 'thumbs/' . rawurlencode($categoryName) . '/' . rawurlencode($thumbName); - - $needsThumb = !file_exists($thumbAbsPath) - || filemtime($thumbAbsPath) < $sourceMtime - || $sourceMtime > $lastIndexedTimestamp; - - if ($needsThumb) { - createThumbnail($sourcePath, $thumbAbsPath, THUMB_WIDTH, THUMB_HEIGHT); + if ($token !== '' && $photoId > 0 && $text !== '') { + $u = commenterByToken($token); + if ($u) { + commentAdd($photoId, (int)$u['id'], mb_substr($text, 0, 1000)); } - - $image['thumb_path'] = $thumbWebPath; - $image['full_path'] = '?action=image&category=' . rawurlencode($categoryName) . '&file=' . rawurlencode($image['filename']); - $image['title'] = titleFromFilename($image['filename']); - $image['mtime'] = $sourceMtime; } - usort($images, static function (array $a, array $b): int { - $bySort = ($a['sort_index'] ?? 0) <=> ($b['sort_index'] ?? 0); - if ($bySort !== 0) { - return $bySort; - } - - return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0); - }); -} -unset($images, $image); - -if ($maxTimestamp > $lastIndexedTimestamp) { - file_put_contents($lastIndexedFile, (string)$maxTimestamp); -} - -$selectedCategory = isset($_GET['category']) ? trim((string)$_GET['category']) : null; -if ($selectedCategory !== null && $selectedCategory !== '' && !isset($categories[$selectedCategory])) { - http_response_code(404); - $selectedCategory = null; -} - -?> - - - - - Фотогалерея - - - - -
-
-

Фотогалерея

-

Простая галерея, которая управляется через файловый менеджер.

-
- - -
-

Категории

- -

Пока нет папок с фото. Загрузите файлы в photos/<категория>/ через FTP.

- -
- $images): ?> - - - - <?= htmlspecialchars($categoryName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?> - - - фото - - -
- -
- -
- - - - -

В этой категории пока нет изображений.

- - - -
- - - -
- - - - - - - 0 ? photoById($activePhotoId) : null; +$comments = $photo ? commentsByPhoto($activePhotoId) : []; +$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : []; + +function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } +function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); } + +function serveImage(): never { - $name = pathinfo($filename, PATHINFO_FILENAME); - $name = str_replace(['_', '-'], ' ', $name); - $name = preg_replace('/\s+/', ' ', $name) ?? $name; - $name = trim($name); - - if ($name === '') { - return $filename; + $fileId = (int)($_GET['file_id'] ?? 0); + if ($fileId < 1) { + http_response_code(404); + exit; } - if (function_exists('mb_convert_case')) { - return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8'); + $f = photoFileById($fileId); + if (!$f) { + http_response_code(404); + exit; } - return ucwords(strtolower($name)); + $abs = __DIR__ . '/' . ltrim((string)$f['file_path'], '/'); + if (!is_file($abs)) { + http_response_code(404); + exit; + } + + if ((string)$f['kind'] !== 'after') { + header('Content-Type: ' . ((string)$f['mime_type'] ?: 'application/octet-stream')); + header('Content-Length: ' . (string)filesize($abs)); + header('Cache-Control: private, max-age=60'); + header('X-Robots-Tag: noindex, nofollow'); + readfile($abs); + exit; + } + + outputWatermarked($abs, (string)$f['mime_type']); } -function ensureDirectories(array $dirs): void +function outputWatermarked(string $path, string $mime): never { - foreach ($dirs as $dir) { - if (!is_dir($dir)) { - mkdir($dir, 0775, true); - } - } -} + $text = 'photo.andr33v.ru'; -function readLastIndexedTimestamp(string $path): int -{ - if (!file_exists($path)) { - return 0; - } - - $value = trim((string) file_get_contents($path)); - return ctype_digit($value) ? (int)$value : 0; -} - -function scanCategories(string $photosDir, array $sortData): array -{ - $result = []; - $categorySortMap = (array)($sortData['categories'] ?? []); - $photoSortMap = (array)($sortData['photos'] ?? []); - - $entries = @scandir($photosDir) ?: []; - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - - $categoryPath = $photosDir . '/' . $entry; - if (!is_dir($categoryPath)) { - continue; - } - - $images = []; - $files = @scandir($categoryPath) ?: []; - foreach ($files as $filename) { - if ($filename === '.' || $filename === '..') { - continue; - } - - $absPath = $categoryPath . '/' . $filename; - if (!is_file($absPath) || !isImage($absPath)) { - continue; - } - - $images[] = [ - 'filename' => $filename, - 'abs_path' => $absPath, - 'sort_index' => (int)(($photoSortMap[$entry][$filename] ?? 1000)), - ]; - } - - $result[$entry] = $images; - } - - uksort($result, static function (string $a, string $b) use ($categorySortMap): int { - $aSort = (int)($categorySortMap[$a] ?? 1000); - $bSort = (int)($categorySortMap[$b] ?? 1000); - - if ($aSort !== $bSort) { - return $aSort <=> $bSort; - } - - return strnatcasecmp($a, $b); - }); - - return $result; -} - -function assetUrl(string $relativePath): string -{ - $file = __DIR__ . '/' . ltrim($relativePath, '/'); - $v = is_file($file) ? (string)filemtime($file) : (string)time(); - return $relativePath . '?v=' . rawurlencode($v); -} - -function loadSortData(string $sortFile): array -{ - if (!is_file($sortFile)) { - return ['categories' => [], 'photos' => []]; - } - - $json = file_get_contents($sortFile); - if ($json === false || trim($json) === '') { - return ['categories' => [], 'photos' => []]; - } - - $data = json_decode($json, true); - if (!is_array($data)) { - return ['categories' => [], 'photos' => []]; - } - - return [ - 'categories' => is_array($data['categories'] ?? null) ? $data['categories'] : [], - 'photos' => is_array($data['photos'] ?? null) ? $data['photos'] : [], - ]; -} - -function isImage(string $path): bool -{ - $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); - return in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif'], true); -} - -function createThumbnail(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void -{ if (extension_loaded('imagick')) { - createThumbnailWithImagick($srcPath, $thumbPath, $targetWidth, $targetHeight); - return; + $im = new Imagick($path); + $draw = new ImagickDraw(); + $draw->setFillColor(new ImagickPixel('rgba(255,255,255,0.22)')); + $draw->setFontSize(max(18, (int)($im->getImageWidth() / 24))); + $draw->setGravity(Imagick::GRAVITY_SOUTHEAST); + $im->annotateImage($draw, 20, 24, -15, $text); + header('Content-Type: ' . ($mime !== '' ? $mime : 'image/jpeg')); + $im->setImageCompressionQuality(88); + echo $im; + $im->clear(); + $im->destroy(); + exit; } - createThumbnailWithGd($srcPath, $thumbPath, $targetWidth, $targetHeight); -} - -function createThumbnailWithImagick(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void -{ - $imagick = new Imagick($srcPath); - $imagick->setIteratorIndex(0); - $imagick->setImageOrientation(Imagick::ORIENTATION_UNDEFINED); - $imagick->thumbnailImage($targetWidth, $targetHeight, true, true); - $imagick->setImageFormat('jpeg'); - $imagick->setImageCompressionQuality(82); - $imagick->writeImage($thumbPath); - $imagick->clear(); - $imagick->destroy(); -} - -function createThumbnailWithGd(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void -{ - [$srcW, $srcH, $type] = @getimagesize($srcPath) ?: [0, 0, 0]; - if ($srcW < 1 || $srcH < 1) { - return; - } - - $src = match ($type) { - IMAGETYPE_JPEG => @imagecreatefromjpeg($srcPath), - IMAGETYPE_PNG => @imagecreatefrompng($srcPath), - IMAGETYPE_GIF => @imagecreatefromgif($srcPath), - IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : null, + [$w, $h, $type] = @getimagesize($path) ?: [0,0,0]; + $img = match ($type) { + IMAGETYPE_JPEG => @imagecreatefromjpeg($path), + IMAGETYPE_PNG => @imagecreatefrompng($path), + IMAGETYPE_GIF => @imagecreatefromgif($path), + IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null, default => null, }; - - if (!$src) { - return; + if (!$img) { + readfile($path); + exit; } - $scale = min($targetWidth / $srcW, $targetHeight / $srcH); - $dstW = max(1, (int) floor($srcW * $scale)); - $dstH = max(1, (int) floor($srcH * $scale)); + $font = 5; + $color = imagecolorallocatealpha($img, 255, 255, 255, 90); + $x = max(5, $w - (imagefontwidth($font) * strlen($text)) - 15); + $y = max(5, $h - imagefontheight($font) - 12); + imagestring($img, $font, $x, $y, $text, $color); - $dst = imagecreatetruecolor($dstW, $dstH); - imagecopyresampled($dst, $src, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH); - - imagejpeg($dst, $thumbPath, 82); - - imagedestroy($src); - imagedestroy($dst); + header('Content-Type: image/jpeg'); + imagejpeg($img, null, 88); + imagedestroy($img); + exit; } +?> + + + + + Фотогалерея + + + + + +
+

Фотогалерея

Простая галерея, которая управляется через файловый менеджер.

+
+ +
+ 0 && $photo): ?> +
+

← к разделу

+

+

+
+
До обработки
+
После обработки (watermark)
+
+ +

Комментарии

+ +
+ + + + +

+
+ +

Комментарии может оставлять только пользователь с персональной ссылкой.

+ + + +
·
+ +
+ +
+

Фотографии

+ +

Выберите раздел слева.

+ +

В разделе пока нет фотографий.

+ +
+ + + +

+
+ +
+ +
+ +
+
+
+ +